Skip to content

Allow tools whose schema is only known at runtime - #100

Merged
tattn merged 1 commit into
tattn:mainfrom
sebastien-burel:runtime-tool-schemas
Aug 26, 2026
Merged

Allow tools whose schema is only known at runtime#100
tattn merged 1 commit into
tattn:mainfrom
sebastien-burel:runtime-tool-schemas

Conversation

@sebastien-burel

Copy link
Copy Markdown
Contributor

A host whose tools come from a registry discovers them while running, as data: a name, a description, and a JSON Schema string. There is no Swift type to hang Arguments.argumentsSchema on, so AnyLLMTool.init(_:) cannot express such a tool at all — which rules the library out for an agent runtime whose tool set is not known at compile time.

Everything AnyLLMTool stores is already dynamic:

private let _argumentsSchema: [String: any Sendable]
private let _call: @Sendable (String) async throws -> ToolOutput

and name / description are already instance requirements on LLMTool. Only the way in is missing. This adds it:

public init(
    name: String,
    description: String,
    argumentsSchema: [String: any Sendable],
    call: @escaping @Sendable (String) async throws -> ToolOutput
)

DynamicLLMTool comes along to answer underlyingTool, which is non-optional, for a tool that has no Swift type of its own.

LlamaClient needed the same door. It takes [any LLMTool] and erases them itself with tools.map { AnyLLMTool($0) }, which routes back through the static-schema initializer — so a caller holding an already-erased tool could not get it through. It now delegates to an erasedTools: variant, with a matching LocalLLMClient.llama(…) overload.

Everything here is additive: no existing call site changes, and the [any LLMTool] initializer keeps its behaviour by delegating.

Why

I am using LocalLLMClientLlama as a local-inference backend for a JS agent runtime, where an agent registers tools at run time and the host executes them itself — the model only needs to be told the schema and to emit the call. Verified end to end against Qwen2.5-3B: the schema reaches the chat template, LlamaToolCallParser surfaces the call, and a multi-round exchange returns real tool output.

LocalLLMClientLlama builds clean on this branch. Split from #99, which carries unrelated packaging fixes; the two are independent.

@tattn

tattn commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Thanks for the contribution!

The runtime tool schemas are lost when going through LLMSession, because LLMSession+Llama.swift converts AnyLLMTool back to underlyingTool and then re-erases it. For DynamicLLMTool, that makes the schema empty.

I think this path should pass the already-erased tools directly, for example:

LocalLLMClient.llama(
    url: url,
    mmprojURL: mmprojURL,
    parameter: parameter,
    erasedTools: tools
)

instead of:

LocalLLMClient.llama(
    url: url,
    mmprojURL: mmprojURL,
    parameter: parameter,
    tools: tools.map { $0.underlyingTool }
)

Also, I fixed the CI issue on the latest main, so please merge or rebase the latest main into this branch.

A host that gets its tools from a registry — an agent runtime, say —
discovers them while running, as data: a name, a description and a JSON
Schema string. There is no Swift type to hang `Arguments.argumentsSchema`
on, so `AnyLLMTool.init(_:)` cannot express such a tool at all.

Everything `AnyLLMTool` stores is already dynamic — `_argumentsSchema` is
a dictionary, `_call` takes the raw arguments JSON — so this only adds
the missing way in: an initializer taking those values directly, plus
`DynamicLLMTool` to answer `underlyingTool` for a tool that has no type
of its own.

`LlamaClient` needed the same door. It took `[any LLMTool]` and erased
them itself, which routes back through the static-schema initializer, so
a caller holding an `AnyLLMTool` could not get it through. It now
delegates to an `erasedTools:` variant that accepts tools already
wrapped, and `LocalLLMClient.llama(…)` gained the matching overload.

`LLMSession+Llama` used that same round trip: `makeClient` already
receives `[AnyLLMTool]`, and mapping them back through `underlyingTool`
re-erased them — emptying the schema of any tool built from runtime data.
Both model factories now pass the erased tools straight through.

Everything is additive: no existing call site changes.
@sebastien-burel

Copy link
Copy Markdown
Contributor Author

Good catch — you're right, and I'd missed it because my own usage calls LlamaClient.responseStream directly rather than going through LLMSession, so that path was never exercised.

Fixed as you suggested: both DownloadModel.llama and LocalModel.llama now pass erasedTools: tools straight through instead of round-tripping via underlyingTool. Rebased onto the latest main.

One thing worth flagging: LLMSession+MLX.swift has the same round trip in both of its makeClient closures, and MLXClient.init re-erases the same way — so a runtime-schema tool would lose its schema there too. I left it out to keep this PR to your review comment, but happy to add the matching erasedTools: overload for MLX if you'd like it here rather than separately.

sebastien-burel added a commit to sebastien-burel/KaozKit that referenced this pull request Aug 26, 2026
The maintainer found a hole in tattn/LocalLLMClient#100 that our own use
could not surface: `LLMSession+Llama` mapped its already-erased tools
back through `underlyingTool` and re-erased them, which empties the
schema of a tool built from runtime data. KaozKit calls
`LlamaClient.responseStream` directly and never goes through
`LLMSession`, so the local verification never touched that path.

The fork now carries the fix and is rebased onto upstream main, which
gained the CI stability work of #101 since.

@tattn tattn left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for taking care of this. The implementation looks good to me.

There are some interfaces that we may want to revise based on this change, but I think we can address those as needed and go ahead with the merge for now.

Thank you again.

@tattn
tattn merged commit b68f712 into tattn:main Aug 26, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants